Esercitazione 2
Esercizio 2.1: esercizio d'esame 2025-09-09
Vediamo ora un esercizio d'esame, del 09 Settembre 2025 (l'ultimo appello). Il testo con soluzione si trova qui.
Provare a svolgere da sé l'esercizio, prima di guardare la soluzione o andare oltre per la discussione.
L'esercizio ci chiede di
- Leggere un numero di 4 cifre in base 7
- Stamparlo in notazione decimale (base 10)
- Testare e stampare se è divisibile per 64, senza usare
div
In particolare, ci è chiesto di risolvere il primo punto scrivendo due sottoprogrammi:
indigit_b7, per la lettura di una cifra in base 7,innumber_b7, per la lettura di un numero a 4 cifre in base 7.
Per entrambi, dovremo gestire l'input ignorando caratteri inattesi: cioè, il programma deve comportarsi come se non fosse stato premuto niente, non stampando nulla e restando in attesa di un carattere corretto (in questo caso, un numero da a ).
Richiami su sottoprogrammi
Partiamo da cosa significa scrivere un sottoprogramma.
Un sottoprogramma è un blocco di istruzioni riutilizzabile:
si entra nel sottoprogramma con una call e, alla fine di questo, tramite ret si ritorna al chiamante riprendendo dall'istruzione successiva alla call.
È infatti questo il meccanismo che sfruttiamo quando utilizziamo i sottoprogrammi di utility, come nello snippet che segue.
...
mov $'h', %ah
mov $'l', %al
call outchar # chiamata a sottoprogramma
cmp $'h', %ah
je ok
...
Il sottoprogramma outchar ci dice, nella sua documentazione, che si occupa di stampare il carattere che trova in %al, in questo caso l.
Parte di ciò che rende un sottoprogramma utile è che faccia quello che dice, e non altro:
per esempio, da questa lettura della documentazione ci aspettiamo che il contenuto di %ah non sia modificato, e che quindi la je avrà sempre successo.
Elenchiamo quindi gli aspetti principali di un sottoprogramma:
- Ci si entra con una
call, si esce con unaret; - Ha input e output (registri, memoria, I/O) chiaramente documentati;
- Non modifica alcun registro, locazione di memoria o I/O al di fuori quanto documentato.
Quando si vìola il terzo punto si parla di effetti collaterali, che è un errore.
indigit_b7
Cominciamo quindi a delineare la nostra indigit_b7 partendo da la sua struttura e documentazione:
# Legge e fa eco, ignorando caratteri inattesi, di una cifra in base 7
# Ne lascia il valore in %al
indigit_b7:
...
ret
Guardiamo ora al requisito di ignorare caratteri inattesi: il sottoprogramma dovrebbe leggere un carattere e controllare se "va bene", se sì lo stampa e continua altrimenti torna a leggere un altro carattere. Questo non è che un ciclo.
Per quanto riguarda il criterio, si tratta di fare un confronto tra caratteri ASCII,
dato che i valori sono consecutivi: tutte le cifre tra e sono, nella tabella ASCII, tra i valori 0x30 e 0x36.
Riassiumiamo vedendo come si farebbe in pseudo-C.
bool continua = true;
char c;
while(continua) {
c = inchar(); // legge un carattere senza farne eco
if(c < '0' || c > '6')
continua = true; // non va bene, ne leggiamo un altro
else
continua = false; // va bene
}
outchar(c); // stampa il carattere letto
Traducendo questo loop in assembler, otteniamo:
# Legge e fa eco, ignorando caratteri inattesi, di una cifra in base 7
# Ne lascia il valore in %al
indigit_b7:
# ...
indigit_b7_loop:
call inchar
cmp $'0', %al
jb indigit_b7_loop
cmp $'6', %al
ja indigit_b7_loop
# arrivati qui, il carattere è ok
call outchar
# ...
ret
Adesso abbiamo un carattere tra 0 e 6, dobbiamo convertirlo in un valore tra e .
Ricordiamo infatti che il valore di un carattere ASCII è un byte generalmente diverso da quello che rappresenta, cioè $'0' non è uguale a $0.
Per convertire da uno all'altro, ricordiamo che le rappresentazioni dei caratteri sono ordinate, e quindi possiamo ottenere un indice per differenza: per esempio '1' - '0' = 1.
Aggiungendo questa sottrazione, otteniamo:
# Legge e fa eco, ignorando caratteri inattesi, di una cifra in base 7
# Ne lascia il valore in %al
indigit_b7:
# ...
indigit_b7_loop:
call inchar
cmp $'0', %al
jb indigit_b7_loop
cmp $'6', %al
ja indigit_b7_loop
# arrivati qui, il carattere è ok
call outchar
sub $'0', %al
# ...
ret
Quello che manca è controllare gli effetti collaterali.
In questo caso non ce ne sono: le istruzioni di indigit_b7, così come la inchar, sporcano solo %al che è lo stesso registro che utilizziamo per l'output.
outchar invece non modifica nessun registro.
Quindi, per questo sottoprogramma, non c'è bisogno di aggiungere push e pop.
# Legge e fa eco, ignorando caratteri inattesi, di una cifra in base 7
# Ne lascia il valore in %al
indigit_b7:
indigit_b7_loop:
call inchar
cmp $'0', %al
jb indigit_b7_loop
cmp $'6', %al
ja indigit_b7_loop
# arrivati qui, il carattere è ok
call outchar
sub $'0', %al
ret
indigit_b7 e indigit_b7_loop?Nel caso visto sopra è chiaramente una distinzione inutile.
Consideriamo però un sottoprogramma leggermente diverso, che usi un altro registro come output, per esempio %bl, senza distinguere le due label.
L'usare un altro registro significa che il valore di %al va preservato, usando push e pop.
# Legge e fa eco, ignorando caratteri inattesi, di una cifra in base 7
# Ne lascia il valore in %bl
indigit_b7:
push %ax # push/pop sono solo da 32 o 16 bit, non 8
call inchar
cmp $'0', %al
jb indigit_b7
cmp $'6', %al
ja indigit_b7
# arrivati qui, il carattere è ok
call outchar
sub $'0', %al
pop %ax
ret
Questo codice ha un bug: con questa struttura, facciamo una nuova push ogni volta che si inserisce un carattere non riconosciuto.
Però, in fondo, si ha solo una pop.
Questo significa che si arriva alla ret con lo stack sporco: questo causa crash del programma (se va bene e non si trovano istruzioni nel punto a caso in cui salta).
È quindi una buona regola, per evitare errori difficili da debuggare, distinguere le label di ingresso dei sottoprogrammi dalle label utilizzate per fare cicli.
Arrivati a questo punto, abbiamo il sottoprogramma indigit_b7 che possiamo utilizzare per ottenere da terminale una cifra in base 7, e trovarne il valore (compreso tra e ) in %al.
Possiamo verificarne il funzionamento cominciando a scrivere il resto del programma per testarlo (download).
.include "./files/utility.s"
.data
.text
_main:
nop
call indigit_b7
call newline
call outdecimal_byte
ret
# Legge e fa eco, ignorando caratteri inattesi, di una cifra in base 7
# Ne lascia il valore in %al
indigit_b7:
indigit_b7_loop:
call inchar
cmp $'0', %al
jb indigit_b7_loop
cmp $'6', %al
ja indigit_b7_loop
# arrivati qui, il carattere è ok
call outchar
sub $'0', %al
ret
Eseguendo questo programma, otteniamo
PS /mnt/c/reti_logiche/assembler> ./assemble.ps1 ./lezioni/2/2025-09-09-p1.s
PS /mnt/c/reti_logiche/assembler> ./lezioni/2/2025-09-09-p1
5
5
PS /mnt/c/reti_logiche/assembler>
innumber_b7
Passiamo ora a scrivere innumber_b7, per la lettura di un numero a 4 cifre in base 7.
Definiamo prima cosa vogliamo che faccia.
Sarebbe infatti utile che questo sottoprogramma si occupi di convertire la sequenza di 4 cifre nel numero naturale rappresentato.
Bisogna prima però chiedersi: quanto sarà grande questo naturale, quanti bit servono? Questo si chiama fare il dimensionamento, ed è una parte importante per tutti gli esercizi che toccano l'aritmetica. Il numero naturale più grande che si può scrivere con 4 cifre in base 7 è . Quindi non ci basta un registro a 8 bit (), ma ce ne basta uno a 16 bit ().
# Legge e fa eco, ignorando caratteri inattesi, di un numero naturale di 4 cifre in base 7
# Ne lascia il valore in %ax
innumber_b7:
...
ret
Siano , , , le 4 cifre in base 7, ciascuna compresa tra e . Il numero naturale rappresentato da queste 4 cifre sarà . Abbiamo quindi bisogno di
- leggere le 4 cifre (possiamo usare
indigit_b7) - moltiplicare ciascuna cifra per una potenza di 7
- poi sommare questi valori tra di loro.
Cominciamo a vedere, in pseudo-C, come potremmo fare questa cosa, ricordandoci che in assembler possiamo fare operazioni aritmetiche (
add,mul) solo fra due operandi alla volta.
// per semplicità, il codice che segue non tiene in considerazione i dimensionamenti per le mul...
int b_3 = indigit_b7();
int b_2 = indigit_b7();
int b_1 = indigit_b7();
int b_0 = indigit_b7();
int p_3 = b_3 * 343; // 7*7*7
int p_2 = b_2 * 49; // 7*7
int p_1 = b_1 * 7;
int p_0 = b_0;
int n = ((p3 + p2) + p1 ) + p0;
Questa scomposizione funziona, e potremmo effettivamente tradurla in una implementazione. C'è un vincolo importante, però: dove mettiamo tutte queste variabili intermedie? Ricordiamo che abbiamo sempre 3 opzioni:
- registri
- memoria statica (
.data) - stack
La prima opzione è preferibile (anche per performance) ma i registri sono limitati, e potrebbe essere difficile gestirli. La seconda opzione funziona, ma è la meno elegante: richiede che il sottoprogramma abbia il proprio spazio di memoria dedicato sempre allocato (equivale ad una funzione C che utilizzi variabili globali). La terza opzione è invece la migliore quando si tratta di usare la memoria in sottoprogrammi: non a caso, un compilatore C utilizza per le variabili locali proprio lo stack.
A fini didattici, vediamo prima come fare questo con lo stack. Dobbiamo prima riordinare le operazioni del nostro pseudo-C per rendere l'idea fattibile. Attenzione: possiamo riordinare i calcoli, ma non l'ordine di input, perché l'utente scriverà sempre il numero partendo dalla cifra più significativa.
push e pop non a 8 bitRicordiamo che le istruzioni push e pop supportano solo operandi a 16 e 32 bit, non 8.
Dobbiamo quindi estendere su almeno 16 bit per utilizzare lo stack.
// per semplicità, il codice che segue non tiene in considerazione i dimensionamenti per le mul...
// fase 1: calcolo prodotti e push
al = indigit_b7();
ax = al * 343;
push(ax);
al = indigit_b7();
ax = al * 49;
push(ax);
al = indigit_b7();
ax = al * 7;
push(ax);
al = indigit_b7();
ax = al;
push(ax);
// fase 2: pop e sommatoria
ax = 0;
// b_0
bx = pop();
ax += bx;
// += b_1 * 7
bx = pop();
ax += bx;
// += b_2 * 7 * 7
bx = pop();
ax += bx;
// += b_3 * 7 * 7 * 7
bx = pop();
ax += bx;
Per tradurre questo in assembler, dobbiamo risolvere i problemi di dimensionamento per utilizzare correttamente la mul.
Infatti, la mul a 8 bit accetta operandi a 8 bit, lasciando un risultato a 16 bit in %ax.
La mul a 16 bit accetta operandi a 16 bit, lasciando un risultato a 32 bit in %dx_%ax.
Noi però vogliamo moltiplicare, ad un certo punto, per , che non sta su 8 bit ().
Quello che dobbiamo fare, almeno per quel passaggio, è quindi utilizzare la mul a 16 bit ignorando la parte alta del risultato in %dx (sappiamo, per dimensionamento, che sarà 0x0000).
In questo esercizio abbiamo potenze di 7, come .
Si potrebbe pensare di calcolare questo nel programma, con una serie di mul.
Sarebbe però uno sforzo del tutto inutile, sia in termini di codice che di cicli del processore.
Se infatti possiamo calcolare in anticipo il risultato (la calcolatrice si può usare) è meglio scriverlo direttamente come costante nel codice, e usare i commenti per dire qual'è il calcolo che vi sta dietro.
# Legge e fa eco, ignorando caratteri inattesi, di un numero naturale di 4 cifre in base 7
# Ne lascia il valore in %ax
innumber_b7:
# push dei registri sporcati
push %bx
push %dx # sporcato dalla mul a 16 bit
# fase 1: calcolo prodotti e push
call indigit_b7
mov $0, %ah
mov $343, %bx
mul %bx
push %ax
call indigit_b7
mov $49, %bl
mul %bl
push %ax
call indigit_b7
mov $7, %bl
mul %bl
push %ax
call indigit_b7
mov $0, %ah
push %ax
# fase 2: pop e sommatoria
mov $0, %ax
# b_0
pop %bx
add %bx, %ax
# += b_1 * 7
pop %bx
add %bx, %ax
# += b_2 * 7 * 7
pop %bx
add %bx, %ax
# += b_3 * 7 * 7 * 7
pop %bx
add %bx, %ax
# pop dei registri sporcati
pop %dx
pop %bx
ret
Questa implementazione del sottoprogramma è la migliore? No. Però funziona, cosa che è l'obiettivo principale da raggiungere.
Infatti, possiamo verificarlo con un programma di test (download).
Notiamo che, visto che il risultato è un naturale su 16 bit, ci basterà usare outdecimal_word per stamparne la rappresentazione decimale.
Versione senza stack
Il sottoprogramma scritto sopra utilizza lo stack per gestire i quattro valori intermedi da calcolare e, infine, sommare. Questa tecnica è utile in generale, soprattutto per conti più complessi che richiedono molte più istruzioni (e registri) per ciascun passaggio intermedio.
Tuttavia, è facile osservare che questo calcolo non è così complesso. Infatti, potremmo usare un altro registro come appoggio per calcolare la somma mentre leggiamo nuove cifre, senza passare per lo stack.
# Legge e fa eco, ignorando caratteri inattesi, di un numero naturale di 4 cifre in base 7
# Ne lascia il valore in %ax
innumber_b7:
# push dei registri sporcati
push %bx
push %cx
push %dx # sporcato dalla mul a 16 bit
# inizializzazione registro d'appoggio
mov $0, %cx
call indigit_b7
mov $0, %ah
mov $343, %bx
mul %bx
add %ax, %cx
call indigit_b7
mov $49, %bl
mul %bl
add %ax, %cx
call indigit_b7
mov $7, %bl
mul %bl
add %ax, %cx
call indigit_b7
mov $0, %ah
add %ax, %cx
# riprendiamo il risultato dal registro d'appoggio
mov %cx, %ax
# pop dei registri sporcati
pop %dx
pop %cx
pop %bx
ret
Qui il programma di test per questa versione.
Versione con loop scalabile
Le versioni sopra hanno un grosso limite: tutti e 4 i casi per le 4 cifre vengono gestiti "a mano". Passare a un numero di cifre richiederebbe scrivere blocchi di codice simili, ma con costanti diverse.
Per trovare un'alternativa iterativa dobbiamo partire dalla formula, capendo come trasformarla per renderla iterativa.